/*
 * Copyright 2009 Google Inc.
 * 
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 * 
 * http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */
package com.google.gwt.dev.js;

import com.google.gwt.core.ext.js.JavaRef;
import com.google.gwt.core.ext.js.JsniFunction;
import com.google.gwt.core.ext.js.JsniFunctionParseContext;
import com.google.gwt.dev.jjs.HasSourceInfo;
import com.google.gwt.dev.jjs.InternalCompilerException;
import com.google.gwt.dev.jjs.SourceInfo;
import com.google.gwt.dev.jjs.ast.HasEnclosingType;
import com.google.gwt.dev.jjs.ast.JArrayType;
import com.google.gwt.dev.jjs.ast.JClassLiteral;
import com.google.gwt.dev.jjs.ast.JClassType;
import com.google.gwt.dev.jjs.ast.JField;
import com.google.gwt.dev.jjs.ast.JLiteral;
import com.google.gwt.dev.jjs.ast.JMethod;
import com.google.gwt.dev.jjs.ast.JParameter;
import com.google.gwt.dev.jjs.ast.JPrimitiveType;
import com.google.gwt.dev.jjs.ast.JProgram;
import com.google.gwt.dev.jjs.ast.JReferenceType;
import com.google.gwt.dev.jjs.ast.JType;
import com.google.gwt.dev.jjs.impl.GenerateJavaScriptLiterals;
import com.google.gwt.dev.js.JsParserException.SourceDetail;
import com.google.gwt.dev.js.ast.JsContext;
import com.google.gwt.dev.js.ast.JsExprStmt;
import com.google.gwt.dev.js.ast.JsExpression;
import com.google.gwt.dev.js.ast.JsFunction;
import com.google.gwt.dev.js.ast.JsModVisitor;
import com.google.gwt.dev.js.ast.JsName;
import com.google.gwt.dev.js.ast.JsNameRef;
import com.google.gwt.dev.js.ast.JsParameter;
import com.google.gwt.dev.js.ast.JsProgram;
import com.google.gwt.dev.js.ast.JsStatement;
import com.google.gwt.dev.js.ast.JsStringLiteral;
import com.google.gwt.dev.js.ast.JsThisRef;
import com.google.gwt.dev.js.ast.JsVisitor;
import com.google.gwt.dev.util.JsniRef;

import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import java.util.TreeSet;

/**
 * Default implementation of {@link JsniFunction}. Based on static information
 * computed at parse time; could be improved with dynamic results updated by
 * optimizing after permutation sharding.
 */
public final class JsniFunctionImpl extends JsniFunction {

  /**
   * A visitor to resolve JSNI refs.
   */
  public static class JsniRefResolver extends JsModVisitor {
    private final JsniFunctionParseContext context;
    private final List<JavaRef> javaRefs;
    private Map<JsNameRef, JavaRef> jsniMap;
    private JsProgram jsProgram;
    private final JProgram program;

    protected JsniRefResolver(JsniFunctionParseContext context,
        JsProgram jsProgram, Map<JsNameRef, JavaRef> jsniMap,
        List<JavaRef> javaRefs) {
      this.context = context;
      this.program = context.getProgram();
      this.javaRefs = javaRefs;
      this.jsProgram = jsProgram;
      this.jsniMap = jsniMap;
    }

    @Override
    public void endVisit(JsNameRef x, JsContext<JsExpression> ctx) {
      String ident = x.getIdent();
      if (ident.charAt(0) == '@') {
        processNameRef(x, ctx);
      }
    }

    public void processNameRef(JsNameRef nameRef, JsContext<JsExpression> ctx) {
      SourceInfo info = nameRef.getSourceInfo();
      String ident = nameRef.getIdent();
      HasEnclosingType node = parseJsniRef(info, ident);
      if (node == null) {
        return; // already reported error
      }

      JavaRef result;
      if (node instanceof JField) {
        result = processField(nameRef, info, (JField) node, ctx);
      } else if (node instanceof JMethod) {
        result = processMethod(nameRef, info, (JMethod) node, ctx);
      } else {
        throw new InternalCompilerException((HasSourceInfo) node,
            "JSNI reference to something other than a field or method?", null);
      }

      // Can be null if replaced by compile-time constant.
      if (result != null) {
        jsniMap.put(nameRef, result);
        javaRefs.add(result);
      }
    }

    protected boolean aliasJavascriptLiterals() {
      return true;
    }
  
    private HasEnclosingType parseJsniRef(SourceInfo info, String ident) {
      JsniRef parsed = JsniRef.parse(ident);
      if (parsed == null) {
        reportJsniError(info, "Badly formatted native reference '" + ident
            + "'");
        return null;
      }

      String className = parsed.className();
      JType type = null;
      if (!className.equals("null")) {
        type = program.getTypeFromJsniRef(className);
        if (type == null) {
          reportJsniError(info, "Unresolvable native reference to type '"
              + className + "'");
          return null;
        }
      }

      if (!parsed.isMethod()) {
        // look for a field
        String fieldName = parsed.memberName();
        if (type == null) {
          if (fieldName.equals("nullField")) {
            return program.getNullField();
          }

        } else if (fieldName.equals("class")) {
          JClassLiteral lit = program.getLiteralClass(type);
          return lit.getField();

        } else if (type instanceof JPrimitiveType) {
          reportJsniError(info, "May not refer to fields on primitive types");
          return null;

        } else if (type instanceof JArrayType) {
          reportJsniError(info, "May not refer to fields on array types");
          return null;

        } else {
          for (int i = 0; i < ((JReferenceType) type).fields.size(); ++i) {
            JField field = ((JReferenceType) type).fields.get(i);
            if (field.getName().equals(fieldName)) {
              return field;
            }
          }
        }

        reportJsniError(info, "Unresolvable native reference to field '"
            + fieldName + "' in type '" + className + "'");
        return null;

      } else if (type instanceof JPrimitiveType) {
        reportJsniError(info, "May not refer to methods on primitive types");
        return null;

      } else {
        // look for a method
        TreeSet<String> almostMatches = new TreeSet<String>();
        String methodName = parsed.memberName();
        String jsniSig = parsed.memberSignature();
        if (type == null) {
          if (jsniSig.equals("nullMethod()")) {
            return program.getNullMethod();
          }
        } else {
          Queue<JReferenceType> workList = new LinkedList<JReferenceType>();
          workList.add((JReferenceType) type);
          while (!workList.isEmpty()) {
            JReferenceType cur = workList.poll();
            for (int i = 0; i < cur.methods.size(); ++i) {
              JMethod method = cur.methods.get(i);
              if (method.getName().equals(methodName)) {
                String sig = JProgram.getJsniSig(method);
                if (sig.equals(jsniSig)) {
                  return method;
                } else {
                  almostMatches.add(sig);
                }
              }
            }
            if (cur.extnds != null) {
              workList.add(cur.extnds);
            }
            workList.addAll(cur.implments);
          }
        }

        if (almostMatches.isEmpty()) {
          reportJsniError(info, "Unresolvable native reference to method '"
              + methodName + "' in type '" + className + "'");
          return null;
        } else {
          StringBuilder suggestList = new StringBuilder();
          String comma = "";
          for (String almost : almostMatches) {
            suggestList.append(comma + "'" + almost + "'");
            comma = ", ";
          }
          reportJsniError(info, "Unresolvable native reference to method '"
              + methodName + "' in type '" + className + "' (did you mean "
              + suggestList.toString() + "?)");
          return null;
        }
      }
    }

    private JavaRef processField(JsNameRef nameRef, SourceInfo info,
        JField field, JsContext<JsExpression> ctx) {
      if (field.getEnclosingType() != null) {
        if (field.isStatic() && nameRef.getQualifier() != null) {
          reportJsniError(info,
              "Cannot make a qualified reference to the static field "
                  + field.getName());
        } else if (!field.isStatic() && nameRef.getQualifier() == null) {
          reportJsniError(info,
              "Cannot make an unqualified reference to the instance field "
                  + field.getName());
        }
      }

      /*
       * We must replace any compile-time constants with the constant value of
       * the field.
       */
      if (field.isCompileTimeConstant()) {
        if (ctx.isLvalue()) {
          reportJsniError(info,
              "Cannot change the value of compile-time constant "
                  + field.getName());
        }

        JLiteral initializer = field.getConstInitializer();
        JType type = initializer.getType();
        if ((type instanceof JPrimitiveType
            || type == program.getTypeJavaLangString())
            && aliasJavascriptLiterals()) {
          GenerateJavaScriptLiterals generator = new GenerateJavaScriptLiterals(
              jsProgram);
          generator.accept(initializer);
          JsExpression result = generator.peek();
          assert (result != null);
          ctx.replaceMe(result);
          return null;
        }
      }

      // Normal: create a jsniRef.
      return context.createJavaRef(field, info, ctx.isLvalue());
    }

    private JavaRef processMethod(JsNameRef nameRef, SourceInfo info,
        JMethod method, JsContext<JsExpression> ctx) {
      JReferenceType enclosingType = method.getEnclosingType();
      if (enclosingType != null) {
        JClassType jsoImplType = program.typeOracle.getSingleJsoImpls().get(
            enclosingType);
        if (jsoImplType != null) {
          reportJsniError(info, "Illegal reference to method '"
              + method.getName() + "' in type '" + enclosingType.getName()
              + "', which is implemented by an overlay type '"
              + jsoImplType.getName() + "'. Use a stronger type in the JSNI "
              + "identifier or a Java trampoline method.");
        } else if (method.isStatic() && nameRef.getQualifier() != null) {
          reportJsniError(info,
              "Cannot make a qualified reference to the static method "
                  + method.getName());
        } else if (!method.isStatic() && nameRef.getQualifier() == null) {
          reportJsniError(info,
              "Cannot make an unqualified reference to the instance method "
                  + method.getName());
        } else if (!method.isStatic()
            && program.isJavaScriptObject(enclosingType)) {
          reportJsniError(
              info,
              "Illegal reference to instance method '"
                  + method.getName()
                  + "' in type '"
                  + enclosingType.getName()
                  + "', which is an overlay type; only static references to overlay types are allowed from JSNI");
        }
      }
      if (ctx.isLvalue()) {
        reportJsniError(info, "Cannot reassign the Java method "
            + method.getName());
      }

      return context.createJavaRef(method, info);
    }

    private void reportJsniError(SourceInfo info, String message) {
      context.reportError(info, message);
    }
  }

  private class JsniParameterImpl extends JsniParameter {
    private boolean isReferenced = false;
    private JsParameter jsParameter;

    public JsniParameterImpl(JsParameter jsParameter) {
      this.jsParameter = jsParameter;
    }

    @Override
    public String getName() {
      return jsParameter.getName().getIdent();
    }

    @Override
    public boolean isReferenced() {
      return isReferenced;
    }

    public void setReferenced() {
      isReferenced = true;
    }

    private JsParameter getJsParameter() {
      return jsParameter;
    }
  }

  /**
   * Resolves the scope of JS identifiers solely within the scope of a method.
   */
  private static class JsParameterResolver extends JsAbstractSymbolResolver {
    private final JsFunction jsFunction;

    public JsParameterResolver(JsFunction jsFunction) {
      this.jsFunction = jsFunction;
    }

    @Override
    public void resolve(JsNameRef x) {
      // Only resolve unqualified names
      if (x.getQualifier() == null) {
        JsName name = getScope().findExistingName(x.getIdent());

        // Ensure that we're resolving a name from the function's parameters
        if (name != null
            && jsFunction.getParameters().contains(name.getStaticRef())) {
          x.resolve(name);
        }
      }
    }
  }

  /**
   * When code is moved from an instance method to a static method, all thisRefs
   * must be replaced with paramRefs to the synthetic this param.
   */
  private class RewriteJsniMethodBody extends JsModVisitor {

    private final JsName thisParam;

    public RewriteJsniMethodBody(JsName thisParam) {
      this.thisParam = thisParam;
    }

    @Override
    public void endVisit(JsThisRef x, JsContext<JsExpression> ctx) {
      ctx.replaceMe(thisParam.makeRef(x.getSourceInfo().makeChild(
          RewriteJsniMethodBody.class, "Devirtualized instance")));
    }

    @Override
    public boolean visit(JsFunction x, JsContext<JsExpression> ctx) {
      // Don't recurse into nested functions!
      return false;
    }
  }

  static JsniFunction parse(JsniFunctionParseContext context, JMethod method,
      String js, JsProgram jsProgram, Map<JsNameRef, JavaRef> jsniMap)
      throws IllegalArgumentException, IllegalStateException {
    // Here we parse it as an anonymous function, but we will give it a
    // name later when we generate the JavaScript
    // during code generation.
    SourceInfo info = method.getBody().getSourceInfo();
    String syntheticFnHeader = "function (";
    boolean first = true;
    for (int i = 0; i < method.params.size(); ++i) {
      JParameter param = method.params.get(i);
      if (first) {
        first = false;
      } else {
        syntheticFnHeader += ',';
      }
      syntheticFnHeader += param.getName();
    }
    syntheticFnHeader += ')';
    StringReader sr = new StringReader(syntheticFnHeader + '\n' + js);
    try {
      // TODO: Really parse from the correct
      // source position!
      List<JsStatement> result = JsParser.parse(info, jsProgram.getScope(), sr);
      JsExprStmt jsExprStmt = (JsExprStmt) result.get(0);
      JsFunction jsFunction = (JsFunction) jsExprStmt.getExpression();
      jsFunction.setFromJava(true);

      // Resolve JSNI refs.
      List<JavaRef> javaRefs = new ArrayList<JavaRef>();
      JsniRefResolver resolver = new JsniRefResolver(context, jsProgram,
          jsniMap, javaRefs);
      resolver.accept(jsFunction);

      // Resolve parameter and local refs.
      JsParameterResolver localResolver = new JsParameterResolver(jsFunction);
      localResolver.accept(jsFunction);

      // Record string literals.
      final Set<String> stringLiterals = new HashSet<String>();
      class RecordStrings extends JsVisitor {
        @Override
        public void endVisit(JsStringLiteral lit, JsContext<JsExpression> ctx) {
          stringLiterals.add(lit.getValue());
        }
      }
      (new RecordStrings()).accept(jsFunction);

      return new JsniFunctionImpl(jsFunction, javaRefs, stringLiterals);
    } catch (IOException e) {
      throw new InternalCompilerException(
          "Internal error parsing JSNI in method '" + method.getName()
              + "' in type '" + method.getEnclosingType().getName() + "'", e);
    } catch (JsParserException e) {
      /*
       * Count the number of characters to the problem (from the start of the
       * JSNI code)
       * 
       * TODO: remove this if was can really parse from the correct source
       * position!
       */
      SourceDetail detail = e.getSourceDetail();
      int line = detail.getLine();
      char[] chars = js.toCharArray();
      int i = 0, n = chars.length;
      while (line > 0) {
        // CHECKSTYLE_OFF
        switch (chars[i]) {
          case '\r':
            // if skip an extra character if this is a CR/LF
            if (i + 1 < n && chars[i + 1] == '\n') {
              ++i;
            }
            // intentional fall-through
          case '\n':
            --line;
            // intentional fall-through
          default:
            ++i;
        }
        // CHECKSTYLE_ON
      }

      i += detail.getLineOffset();
      SourceInfo errorInfo = context.getProgram().createSourceInfo(i, i, line,
          info.getFileName());
      context.reportError(errorInfo, e.getMessage());
      return null;
    }
  }

  private final List<JavaRef> javaRefs;
  private final JsFunction jsFunction;
  private transient List<JsniParameter> lazyParams;

  private final Set<String> stringLiterals;

  public JsniFunctionImpl(JsFunction jsFunction, List<JavaRef> javaRefs,
      Set<String> stringLiterals) {
    this.jsFunction = jsFunction;
    this.javaRefs = (javaRefs.size() > 0)
        ? Collections.unmodifiableList(javaRefs)
        : Collections.<JavaRef> emptyList();
    this.stringLiterals = (stringLiterals.size() > 0)
        ? Collections.unmodifiableSet(stringLiterals)
        : Collections.<String> emptySet();
  }

  @Override
  public void convertToStatic() {
    JsName paramName = jsFunction.getScope().declareName("this$static");
    jsFunction.getParameters().add(
        0,
        new JsParameter(jsFunction.getSourceInfo().makeChild(
            JsniFunctionImpl.class, "cloneAsStatic"), paramName));

    RewriteJsniMethodBody rewriter = new RewriteJsniMethodBody(paramName);
    rewriter.accept(jsFunction.getBody());

    // Invalidate params cache.
    lazyParams = null;
  }

  @Override
  public List<JavaRef> getJavaRefs() {
    return Collections.unmodifiableList(javaRefs);
  }

  public JsFunction getJsFunction() {
    return jsFunction;
  }

  @Override
  public List<JsniParameter> getParameters() {
    if (lazyParams == null) {
      // Create the list.
      final JsniParameterImpl[] result = new JsniParameterImpl[jsFunction.getParameters().size()];
      for (int i = 0; i < result.length; ++i) {
        result[i] = new JsniParameterImpl(jsFunction.getParameters().get(i));
      }
      // See which ones are referenced.
      new JsVisitor() {
        @Override
        public void endVisit(JsNameRef nameRef, JsContext<JsExpression> ctx) {
          JsName name = nameRef.getName();

          if (name != null) {
            int index = jsFunction.getParameters().indexOf(name.getStaticRef());
            if (index != -1) {
              result[index].setReferenced();
            }
          }
        }
      }.accept(jsFunction);
      lazyParams = Arrays.<JsniParameter> asList(result);
    }
    return lazyParams;
  }

  @Override
  public Set<String> getStringLiterals() {
    return stringLiterals;
  }

  @Override
  public void pruneParameters(List<JsniParameter> parametersToPrune) {
    List<JsParameter> jsParams = jsFunction.getParameters();
    for (JsniParameter param : parametersToPrune) {
      JsniParameterImpl jsniParam = (JsniParameterImpl) param;
      assert !jsniParam.isReferenced;
      jsParams.remove(jsniParam.getJsParameter());
    }
    // Invalidate params cache.
    lazyParams = null;
  }
}
